SPB Git

spb/autoscholar

Public
2.8 KB · 59 lines typescript
1import { AppError, notFound } from "@autoscholar/core";2import { papers, paperVersions } from "@autoscholar/db";3import { publicArtifactKey } from "@autoscholar/storage";4import { and, eq } from "drizzle-orm";5import { handler } from "@/lib/server/api";6import { db, storage } from "@/lib/server/db";78export const runtime = "nodejs";910/** Public artifact delivery: only files listed in the published version's artifact set; immutable; integrity header. */11export const GET = handler(async (req, { params }) => {12  const { publicId, version, path } = (await params) as { publicId: string; version: string; path: string[] };13  const vn = Number(version.replace(/^v/, ""));14  if (!Number.isInteger(vn) || vn < 1) throw notFound("version");15  const rel = path.map(decodeURIComponent).join("/");16  if (rel.includes("..") || rel.startsWith("/")) throw new AppError("invalid", "bad path", 400);17  const d = db();18  const [row] = await d19    .select({ p: papers, v: paperVersions })20    .from(papers)21    .innerJoin(paperVersions, and(eq(paperVersions.paperId, papers.id), eq(paperVersions.versionNo, vn)))22    .where(eq(papers.publicId, publicId.toUpperCase()))23    .limit(1);24  if (!row) throw notFound("paper version");25  if (process.env.STAGING_MODE !== "1" && row.p.synthetic) throw notFound("paper version");26  if ((row.v.metadata as { artifactsWithdrawn?: boolean }).artifactsWithdrawn)27    return new Response("Artifacts withdrawn. See the paper page for the notice.", {28      status: 410,29      headers: { "content-type": "text/plain" },30    });31  const entry = row.v.artifactSet.find((a) => a.path === rel);32  if (!entry) throw notFound("artifact");33  const key = publicArtifactKey(row.p.publicId, vn, rel);34  const st = storage();35  const head = await st.head(key);36  if (!head) throw notFound("artifact object");37  if (head.sha256 !== entry.sha256) throw new AppError("integrity", "artifact integrity check failed", 500);38  const stream = await st.stream(key);39  const inline =40    /^(application\/pdf|image\/|text\/)/.test(entry.contentType) &&41    new URL(req.url).searchParams.get("download") !== "1";42  const filename = rel.split("/").pop() ?? "file";43  const disp = `${inline ? "inline" : "attachment"}; filename="${row.p.publicId}-v${vn}-${filename}"`;44  const webStream = (await import("node:stream")).Readable.toWeb(stream) as ReadableStream;45  return new Response(webStream, {46    headers: {47      "content-type": entry.contentType,48      "content-length": String(entry.bytes),49      "content-disposition": disp,50      "x-artifact-sha256": entry.sha256,51      "cache-control": "public, max-age=31536000, immutable",52      "accept-ranges": "none",53      ...(entry.contentType.startsWith("text/") || entry.contentType === "application/json"54        ? { "content-security-policy": "sandbox" }55        : {}),56    },57  });58});59
Files · main
Menu

Theme